"use client"; import * as React from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ArrowLeft, FlaskConical } from "lucide-react"; import type { GameDefinition, GameSymbol } from "@spinza/game-core"; import { Button } from "@/components/ui"; import { useAdminQuery } from "@/components/admin/use-query"; import type { GameDailyRow, GameDetailResponse, GameVersionRow, SimulationRunRow } from "@/components/admin/types"; import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, TileSkeleton, KV, Pill, LifecyclePill, PassFail, StatGrid, StatTile, Mono, type Column } from "@/components/admin/primitives"; import { Bars, ChartFrame, CHART, Lines } from "@/components/admin/charts"; import { CertificationBlock } from "@/components/admin/simulation-results"; import { dateTime, int, isoDay, multiplier, num, pct, sc, shortDate, signedPct, titleCase } from "@/components/admin/format"; import { VOLATILITY_LABEL, cn } from "@/lib/utils"; export default function AdminGameDetailPage() { const { slug } = useParams<{ slug: string }>(); const q = useAdminQuery(slug ? `/api/admin/games/${slug}` : null); const d = q.data; const [certVersion, setCertVersion] = React.useState(null); const anchor = q.updatedAt; const daily = React.useMemo(() => (d ? fillDays(d.daily, anchor) : []), [d, anchor]); const currentVersion = d?.versions.find((v) => v.version === d.game.version) ?? d?.versions[0] ?? null; const shownCert = d?.versions.find((v) => v.version === (certVersion ?? currentVersion?.version))?.certification ?? null; return ( <> {d.game.slug} · v{d.game.version} {currentVersion?.certification ? : not certified} ) : undefined } actions={ <> void q.refresh()} loading={q.refreshing} /> } /> {q.error && !d ? ( void q.refresh()} title={q.error.status === 404 ? "Game not found" : undefined} /> ) : !d ? (
) : (
{/* Stats */} {sc(d.stats?.wagered ?? 0)} / {sc(d.stats?.won ?? 0)}} compact />
{/* Definition */} {d.definition ? :

The slug is in the database but no definition is registered in @spinza/games.

}
{/* Daily charts */}
shortDate(String(v))} barSize={8} /> shortDate(String(v))} barSize={8} /> r.rtp !== null)} x="day" series={[{ key: "rtp", label: "RTP", color: CHART.accent }]} xFormat={(v) => shortDate(String(v))} yFormat={(v) => `${Math.round(v * 100)}%`} reference={d.definition ? { y: d.definition.rtp } : undefined} dots />
{daily.every((r) => r.spins === 0) ?

No rounds recorded in the last 30 days.

: null}
{/* Symbols */} {d.definition ? ( ) : null}
{/* Versions */} columns={[ { key: "version", header: "Version", render: (v) => v{v.version}{v.version === d.game.version ? current : null} }, { key: "status", header: "Status", render: (v) => {v.status} }, { key: "cert", header: "Certification", render: (v) => (v.certification ? : none) }, { key: "created", header: "Created", render: (v) => {dateTime(v.createdAt)} }, { key: "hash", header: "Hash", mono: true, render: (v) => {v.definitionHash.slice(0, 12)} }, ]} rows={d.versions} rowKey={(v) => v.version} dense onRowClick={(v) => setCertVersion(v.version)} rowClassName={(v) => (v.version === (certVersion ?? currentVersion?.version) ? "bg-surface-2" : undefined)} empty="No versions recorded." /> {/* Simulation runs */} columns={runCols} rows={d.simulationRuns} rowKey={(r) => r.id} dense empty="No simulations yet — run one from the simulator." />
{shownCert ? ( ) : (

No certification report for v{certVersion ?? d.game.version}. Publishing is blocked until a simulation with certify enabled passes for the current version.

)}
)} ); } const runCols: Column[] = [ { key: "created", header: "Started", render: (r) => {dateTime(r.createdAt)} }, { key: "spins", header: "Spins", align: "right", render: (r) => int(r.spins) }, { key: "status", header: "Status", render: (r) => {r.status === "running" ? `${Math.round((num(r.progress) / Math.max(1, num(r.spins))) * 100)}%` : r.status} }, { key: "rtp", header: "Observed RTP", align: "right", render: (r) => (r.result?.simulation ? {pct(r.result.simulation.observedRtp, 3)} ({signedPct(r.result.simulation.deviation, 2)}) : "—") }, { key: "cert", header: "Cert", render: (r) => (r.result?.certification ? : —) }, { key: "open", header: "", align: "right", render: (r) => Open → }, ]; function effRtp(wagered: unknown, won: unknown): number | null { const w = num(wagered); return w ? num(won) / w : null; } function fillDays(rows: GameDailyRow[], anchor: number) { const byDay = new Map(rows.map((r) => [isoDay(r.day), r])); const out: { day: string; spins: number; players: number; rtp: number | null }[] = []; for (let i = 29; i >= 0; i--) { const d = new Date(anchor - i * 86400_000).toISOString().slice(0, 10); const r = byDay.get(d); out.push({ day: d, spins: r ? num(r.spins) : 0, players: r ? num(r.players) : 0, rtp: r && num(r.wagered) ? num(r.won) / num(r.wagered) : null }); } return out; } const FEATURE_KEYS: (keyof GameDefinition)[] = ["wild", "scatter", "cascades", "spinCollect", "holdRespin", "pickBonuses", "meter", "heat", "mystery", "randomFeatures", "quantum", "dynamicGrid", "jackpot"]; function DefinitionSummary({ def }: { def: GameDefinition }) { const mechanics = FEATURE_KEYS.filter((k) => def[k] !== undefined); return (
Features
{def.featureNames.map((f) => ( {f} ))} {def.featureNames.length === 0 ? — : null}
Mechanics configured
{mechanics.map((m) => ( {titleCase(String(m))} ))} {def.freeSpinsPersistentMultiplier ? Persistent FS multiplier : null}
Presentation
{(["primary", "secondary", "glow", "bg", "surface"] as const).map((k) => ( {k} ))} · {def.presentation.particles} · {def.presentation.frame} · {def.presentation.backdrop}
{def.tags.length ?
{def.tags.map((t) => `#${t}`).join(" ")}
: null}
); } function SymbolsTable({ def }: { def: GameDefinition }) { const counts = Array.from({ length: Math.max(0, def.grid.reels - 2) }, (_, i) => i + 3); const fmtWeight = (w: number | number[] | undefined) => (w === undefined ? "—" : Array.isArray(w) ? w.join(" / ") : String(w)); const cols: Column[] = [ { key: "id", header: "Symbol", render: (s) => ( {s.name} {s.id} ), }, { key: "kind", header: "Kind", render: (s) => {s.kind} }, { key: "tier", header: "Tier", render: (s) => {s.tier} }, { key: "weight", header: "Weight", align: "right", mono: true, render: (s) => fmtWeight(s.weight) }, { key: "fsweight", header: "FS weight", align: "right", mono: true, render: (s) => (s.freeSpinWeight !== undefined ? fmtWeight(s.freeSpinWeight) : —) }, ...counts.map>((c) => ({ key: `pay${c}`, header: `${c}×`, align: "right", render: (s) => { const p = s.pays?.[c]; const sp = s.scatterPays?.[c]; if (p === undefined && sp === undefined) return ·; return ( {p !== undefined ? p : null} {sp !== undefined ? {p !== undefined ? " / " : ""}{sp}×bet : null} ); }, })), ]; return s.id} dense />; }